Skip to content

12.1: performance pass, alpha-14 fixes, Important Debuffs on by default - #224

Merged
DanderBot merged 40 commits into
DanderBot:feature/aura-factory-12.1from
Krathe82:krathe/editmode-deaf
Aug 3, 2026
Merged

12.1: performance pass, alpha-14 fixes, Important Debuffs on by default#224
DanderBot merged 40 commits into
DanderBot:feature/aura-factory-12.1from
Krathe82:krathe/editmode-deaf

Conversation

@Krathe82

@Krathe82 Krathe82 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Everything on the 12.1 lane since v5.0.0-alpha.14: a profiling pass across the addon, the fixes that came out of alpha testing, and one default change.

40 commits, 30 files, +2115 / −446.

Performance

The bulk of the diff. Every change is the same shape — reuse work instead of redoing it — and each was measured before and after against the same scenario rather than reasoned about.

Aura rows tune in place. Changing a filter, sort order or icon cap used to tear the container down and rebuild it. The Aura Designer's placed indicators, bar and expiry-alert companions, overlay indicators, and the engine's overlay containers now push the change through the slot setters instead.

Strings and tables that were being rebuilt every frame are now cached. Border spec keys, the font outline parse and family cache key, the Text Designer's appearance resolver (which was also being called twice and throwing one result away), its Live data source, and the duration ladder the aura and Aura Designer signature builders walk.

The highlight pass skips when nothing that affects it changed, instead of restyling on every refresh.

Test mode was giving each preview slot its own aura group. A group eagerly creates a batch of frames before maxFrameCount is applied, so a preview showing five icons paid for far more than five. Rows that can only ever show one icon now use AddAuraSlot, which creates exactly one frame and builds the same comparator from sortMethod/sortDirection — so a max=1 group and a slot pick the same winner. Seven Aura Designer configurations were in that position. Leaving test mode also stopped rebuilding every live handle alongside the preview ones.

The dispel overlay was building containers onto empty raid slots. A raid header keeps its whole child set alive and hands units out as the roster fills, so outside a full group most children carry no unit. Login built 85 containers; 45 were dispel overlays and 40 of those sat on empty slots — hidden, unable to ever show a dispellable aura, and pointed at the wrong unit because a nil unit defaults to player. Now 45 containers with none wasted; secure slot inits fell 270 → 30.

Measured

Three builds, five scenarios each, folded from full traces. Indexed against the shipping 4.10 build:

memory CPU
Retail 4.10 100 100
12.1, container port only 77 76
12.1, after this pass 51 48

Roughly half the drop came from the port and half from this pass.

Before and after the pass — both on 12.1, both on the Test profile, MB and ms:

scenario memory CPU
test mode, on/off ×2 1959.9 → 523.6 976.5 → 279.0
profile switch ×3 408.1 → 220.7 252.1 → 137.1
boss fight 261.8 → 208.5 248.0 → 128.1
dungeon trash 159.2 → 131.5 146.1 → 134.5
login, 5-frame party 283.8 → 248.9 99.3 → 88.4

12.1 against the live build — both on the Default profile, which is byte-identical across the two lanes:

scenario memory CPU
dungeon trash −51% −45%
boss fight −51% −55%
test mode, on/off ×2 −26% −46%
profile switch ×3 −44% −57%
login, 5-frame party −75% −56%

These are addon-side Lua only. The profiler cannot see Blizzard's C++ container work, and 12.1 deliberately moves aura work there while 4.x did the equivalent in Lua where every byte is counted. So this is half the addon-side Lua cost, not half the total work. It is still the number that matters for hitching — Lua allocation is what drives garbage collection.

Two other caveats worth carrying: the Default profile exercises the core frame path only (no Aura Designer indicators, no Targeted List, no proc glow, on either lane), and part of the 12.1-vs-retail gap is the load-on-demand split and the border and font memoisation rather than the container port — those are portable back to 4.x.

Fixes

  • Important Debuffs did nothing for about half the debuffs it covers while the Blizzard category filters were in use. Boss/role and priority were being outranked by the Crowd Control, Raid and Dispellable records, so an important debuff carrying one of those tags — most of them, in group content — landed in an unstyled record. The important records now claim first and the token records subtract them, which is the direction Show All mode always used.
  • Dispel overlay "forbidden object" error storm after a dungeon fight, which also left the overlay dead on the affected frames until reload. Reported at 32x, then 98x on a single settings dropdown. Same defect class as the health mirror's #1004: the art is built onto the game's protected aura slots and the client can reclaim a slot underneath us. It now probes before painting and rebuilds rather than throwing on every refresh.
  • Role icon and role-coloured resource bar showed nothing when the group has assigned no roles — both read the assigned role only, so a tank in a party where nobody set one got no tank icon. They fall back to the spec's role.
  • Aggro, hover and selection highlights drew underneath the frame content rather than above it.
  • Slider and drag hit-testing was wrong at any settings-window scale other than 100% — the cursor was converted through the screen's scale instead of the window's, so grabbing a handle picked its neighbour and clicking a track jumped to the wrong value.
  • Settings-window geometry was stored per profile, so switching or creating a profile moved and rescaled the open window. It is account-wide now.
  • "Sync with Raid" overwrote the raid's own growth direction.
  • Aura filter import committed a name you never saw, so two rows could read identically with no way to tell them apart from the Buffs page. (Reported by Rytiou.)
  • /df debug diagnostics for the main addon no longer load the settings addon.
  • Blizzard Edit Mode: our aura rows keep showing real auras while it is open, and the position panel, alignment grid, movers and any test preview now stand down with the rest instead of sitting on top of Blizzard's editor.
  • Test mode ownership is tracked per requester, so unlocking and your own toggle no longer cancel each other out.

Default change

Important Debuffs is now on by default. The v5 lane rebuilds the aura row anyway, so the "don't change a look every user already has" reason for shipping it off no longer applies. Only the shipped default moves — profiles are a deep copy of the defaults taken at creation, so existing profiles keep their stored value and this affects new profiles only.

Changelog

Covered the work above, and removed one Known Issue that is no longer true:

A debuff that counts as both a Priority debuff and a Boss/Role debuff can show one icon per matching filter when both are enabled.

The priority record subtracts boss/role through its own candidateFilters flag, and the crowd-control, raid and dispel records subtract both. Every record is mutually exclusive by construction now, in category mode and in Show All alike.

The other five Known Issues were each checked against the code and all still hold — the overlapping dispel type icons are still a documented trade-off in StyleGameTypeIconSlot, the Aura Designer's colour-by-cover mechanism is unchanged, and the settings-window border derivation was not touched by the hit-testing fix.

Testing status

Verified in game: the perf work (five scenarios, five passes each, plus a same-profile comparison against the 4.10 build), test mode, and the Edit Mode behaviour.

Not yet verified in game:

  • The dispel forbidden-art recovery. The guard provably stops the throw, but whether the rebuilt art is touchable needs the reporter's conditions — dungeon, roster churn, then a settings change. If the rebuild also comes back forbidden it degrades to a skip with one debug line instead of an error storm, and we would know the cause is birth context rather than reclaim.
  • The Important Debuffs precedence fix. Root-caused and fixed, but the retest never happened — the report that it was still broken came from a build that predated the fix. Worth confirming before this default goes out on, since new users will meet that path first.

Krathe82 added 30 commits August 2, 2026 19:21
Opening Edit Mode used to blank every DF aura row. We hid them, because the
alternative was watching Blizzard's sample icons render in our rows -- correct
against build 68412, where the rows had no other defence.

68569 changed the shape of the problem. CustomAuraContainer was folded onto
ManagedAuraContainer, so parsing runs through GetAuraSources(), which returns
the Edit Mode source list whenever the container's own useEditModeSource flag
is set. Exactly one thing sets it:

    ManagedAuraContainerPrivateMixin:OnAuraDataProviderSwitch(useReal)
        self:SetUseEditModeSource(not useReal)

ResetAuraDataProvider carries no HasRestrictions in the generated docs, unlike
its neighbours SetGroupBuffVisualAlerts and SetHiddenGroupBuffs, and test mode
already drives it. So our containers do not need to ignore the switch -- we
hand the real provider straight back and every container, ours and Blizzard's,
returns to the real source. No teardown, no button pools recreated.

THE TRADE, and it is global: Blizzard's own Edit Mode preview loses its sample
auras, so their buff frame shows your real ones while you position them. Same
class of trade DF test mode already makes in the other direction; the
alternative is our rows flashing.

Order, each step load-bearing:
  1. PARK synchronously inside the dispatch. AURA_DATA_PROVIDER_SWITCH is
     synchronous, so nothing is drawn between the containers' handler and ours
     whichever runs first -- the sample icons are never painted at all.
  2. RESET one frame later, never inline. An inline reset nests a dispatch
     inside this one, and any container the outer fake dispatch had not yet
     visited would take fake AFTER our reset and strand on the sample source.
  3. Fall back to REBIRTH only if the reset is unavailable or fails: a
     container built after a switch never receives it and useEditModeSource
     initialises false, so a fresh one is on the real source by construction.

Test mode sets _ownsProviderSwitch around its own switches, so its curated
preview is exempt from all of this.

DEAD END, kept deliberately: unregistering AURA_DATA_PROVIDER_SWITCH on the
container is refused BY DESIGN, not by accident.
Enum.ForbiddenAspect.EventRegistrations restricts modifying an object's
registered events; every Register* method ADDS that aspect and UnregisterEvent
CHECKS it, and Blizzard's OnLoad_Intrinsic registers the event from secure
code -- so the container carries the aspect from birth and no ordering helps.
The probe stays, run once and never retried, so a build that relaxes the rule
gets noticed instead of assumed away.

Squashed from 11 commits: the deafening attempt above, a rebuild-everything
mechanism that the provider hand-back replaced, an instrumented experiment
into whether the client dispatches a re-entrant switch NESTED or QUEUED, and
the refinements those made unnecessary.
Two independent things want test frames on screen -- the USER (test panel,
/df test, toolbar) and UNLOCK (which needs frames to have something to drag) --
and one boolean could not tell them apart.

Unlock snapshotted DF.testMode; lock trusted the snapshot. Anything that
changed test mode DURING the unlock session left it stale, and it failed both
ways. Field-reported, both orders:

  test on -> unlock -> close the panel -> lock
      snapshot says true, so lock keeps a preview nobody asked for. Stuck on
      until you toggle test mode or enter combat.
  unlock -> enable test -> lock
      snapshot says false, so lock kills a preview you had just turned on.

The "Cannot disable test mode while frames are unlocked" error existed only to
stop the snapshot going stale -- and it never even covered the toolbar button,
which closes the PANEL without going through ToggleTestMode. That was the
reported repro: the button looked like it disabled test mode, and nothing
refused it.

Replaced with ownership (TestMode/Shim.lua, resident because unlock/lock are):
frames show while ANY owner wants them. Unlock claims on unlock and releases on
lock; the user claims via the panel and the toggle. Lock releases only unlock's
claim, so a preview you asked for survives it and one you did not disappears
with it. Both reported orders come out right without special-casing, and the
refusal is gone.

Show*/Hide*TestFrames stay the only thing that moves frames, so everything
downstream -- PinnedFrames' testModeActive and its mover chrome, the aura
containers, the animation driver -- keeps working without knowing ownership
exists. Paths that hide directly drop their claims so no owner outlives the
frames.

The UI consequences, all of them fallout from the model rather than separate
features:
  - The panel's toggle reflects the USER's claim, not whether a preview is on
    screen. Those differ while unlocked, and driving it from the preview made
    a legitimate click read as doing nothing.
  - Turning it off while unlocked says why the frames stay and what will clear
    them. The old code refused outright, which was at least feedback; silent
    correct behaviour is worse to use.
  - Both buttons perform one action -- turn the preview off and close the
    panel -- and report the same message and the same state, whichever one you
    click.
  - The toolbar glyph tracks the preview rather than whether the panel is
    open, since the panel can be closed with a preview still running.

Leaves the now-unused refusal locale key in place: removing it across every
locale belongs with the pending locale audit, not here.

Squashed from 4 commits (the model, then three rounds of UI follow-up from
testing it).
Toggling test mode in raid mode said nothing, while party printed "Test
mode enabled/disabled." Pre-existing asymmetry, not from the ownership
work -- ShowRaidTestFrames and HideRaidTestFrames never had the Say at all.

Neither even took `silent`, although Panel.lua has been calling
HideRaidTestFrames(true) with a "-- silent" comment for a while; that
argument has quietly done nothing. Both halves now take it and honour it,
so the mode-switch and Click-Casting-tab paths stay quiet as they intended
and the user-driven ones speak up.
/edit left DF half-dressed. The game closes DandersFramesGUI and
DandersFramesTestPanel for us -- both happen to be in UISpecialFrames --
but the position panel is not, so it stayed up, and behind it the grid,
the movers and the test preview kept running. Two grids overlapping, and
fake party frames sitting on top of Blizzard's own editor.

The real gap is that DF had no Edit Mode integration at all: the two
windows that did close were closing by accident of UISpecialFrames
membership, not because anything handled Edit Mode.

Locking is the right response and needed nothing new -- LockFrames and
LockRaidFrames already hide the position panel, grid, movers and pinned
drag chrome, and release unlock's test claim. Dropping the user's claim as
well means no preview survives into Edit Mode; that also hands the real
aura data provider back, which keeps Edit Mode's sample auras out of our
rows.

Not restored on exit, deliberately. Blizzard's Edit Mode does not put
anyone else's windows back either, and silently re-unlocking frames under
someone who has just rearranged their UI is worse than making them click
Unlock.
Held until confirmed in game, per the usual rule. Krathe has now run the
nine-step raid sequence from the original report and it no longer sticks.

Four entries: the stuck/self-disabling preview (and the refusal message
going away with it), the two test buttons becoming one control, raid mode
finally confirming enable/disable in chat, and DF standing down when
Blizzard's Edit Mode opens.

Written from what the user sees rather than the mechanism -- "two things
want test frames" reads as an explanation, not an excuse, and the reported
symptom (stuck on, or switched off under you) is what people will search
the changelog for.
Reported by Rytiou, with a repro for each half.

ImportFilterPayload took the decoded name verbatim, and FindContentMatch
compares SPELLS only -- so an import whose name already existed was not
examined by anything. You got two rows reading the same thing, holding
different spell lists, and no way to tell from the Buffs page which one you
had just ticked. Worse through "Import as Copy", where the option that
promises a distinguishable copy produced a row identical in name AND
spells.

Filter names are not unique and deliberately stay that way -- New, Rename
and Duplicate all let you pick freely, and people have deliberately similar
names saved. The actual defect is narrower: import was the ONE path that
committed a name without showing it to you. Duplicate has always pre-filled
"<name> copy" and prompted.

So import now asks, but only when the name would clash: a clean import is
still a single paste, and a clashing one gets Duplicate's treatment with a
suggestion that does not collide. Both entry points -- the plain import and
"Import as Copy" -- go through it.

The prompt is chained; popups are a singleton, and the copy route opens it
from inside another popup's handler.

Includes its changelog entry (squashed from 2 commits).
placedStructSig carried includeSig(map) -- the tracked spell-ID whitelist --
so changing which spells an indicator watches forced Handle:Rebuild.

That is a teardown+recreate, and teardown can only Hide(): WoW never destroys
frames. Every such edit therefore stranded the container plus a 10-frame batch
per group permanently (AddAuraGroup always calls CreateFrameBatch with
FrameCreationBatchSize = 10, before maxFrameCount is even applied). A profile
switch measured 29.95 MiB / 157 ms in one frame at party size, and none of it
came back.

The map is live-mutable: it becomes config.candidateFilters, which
SetAuraGroupCandidateFilters mutates in place. applyGroupTuning already says so
in its own comment -- "include/excludeSpellIDs live in the TUNING signature, not
the struct one". The filter-group path has worked this way since Wave 1; the
placed-indicator path was the outlier.

Split it to match: placedStructSig keeps only create-only properties, the new
placedTuningSig carries the map, and the call site gains the tuning branch
(ApplyTuning off a fresh config, testEntries swapped so a test-mode rebuild
previews the new selection). buildPlacedConfig has the same shape as
buildFilterGroupConfig -- plain string filter, config-wide candidateFilters,
max = 1, no sort -- so the proven pattern transfers unchanged.

Alpha stays inside the cosmetic branch: it is already part of coSig, and this
block runs per indicator per tick.
Same split as the placed icon/square path: the tracked spell-ID map comes out of
barStructSig / alertCompanionStructSig and rides the shared placedTuningSig, so a
selection edit is an ApplyTuning instead of a teardown+recreate that permanently
strands the container and its 10-frame batches.

Both are mode = "row" configs carrying testEntries and a config-wide
candidateFilters, so applyGroupTuning handles them exactly as it already handles
the filter-group path.

The companion's steady-state early-out now compares the tuning sig as well, or a
selection edit would be skipped entirely rather than applied.

Remaining sites (placed/frame-level missing-buff, overlay tint, health mirror,
border) are mode = "overlay"/"missing", where applyGroupTuning early-returns.
Those need the slot-side setters wired up first and are left alone here.
RESOLVERS.group is the single biggest allocator in the addon -- 38% of a
follower-dungeon trace and 43.6% of a boss trace, #1 in both -- and almost all of
it was scratch. Every call built a fresh `parts` table plus a fresh 14-field
element table PER ITEM, from config that only changes when the user edits it.

Both now reuse module-level tables. Safe because groups cannot nest: the Text
Designer's Add Item picker passes excludeKey = "group", so RESOLVERS[typeKey]
can never route back into this resolver and it is never re-entered. No resolver
retains its elem argument either -- they only read fields, and applyNameTrunc
reads just nameLength/truncateMode. `parts` never escapes: it is appended,
iterated, and the function returns a plain string.

Every field of the shared element table is assigned on every iteration, nil
included, so nothing leaks from one item to the next.

Also guarded the four DF:Debug calls with DF:DebugActive("TD"). Arguments are
evaluated by the caller, so the unguarded per-call one allocated two strings via
tostring() on every tick even with the trace switched off. Matches the existing
guard pattern in ClickCasting.
pcall(function() ... end) allocates a fresh closure every call purely to wrap
one method call. In the build and tuning loops that is per group (or per slot)
per container per unit frame, on the paths that already dominate every rebuild
trace. pcall(fn, args...) does the same job and allocates nothing; the
protection is identical, since these calls are guarded because AddAuraGroup and
friends assert.

Converted the five that sit inside per-key loops: AddAuraGroup (test pin groups
and the record loop), AddAuraSlot, the slot SetAllPoints, the three setters in
applyGroupTuning, and SetAuraGroupLayout in applyLayout. Left the once-per-
container ones alone (SetEnabled, SetUnit) and the Hide/Show bounces, which are
two statements and cannot take this form.

Also recorded the real cost shape found in the Blizzard source while here:
SetAuraGroupMaxFrameCount and SetAuraGroupSortMethod only MarkDirty and those
flags coalesce into a single ProcessDirtyFlags next OnUpdate, so they are near
free at any group count -- but SetAuraGroupCandidateFilters runs an immediate
UpdateAllAuras per call with no equality guard, so N groups cost N full updates.
There is no batch setter, so the only lever there is declaring fewer groups.
applyGroupTuning bailed out for both "overlay" and "missing" with no explanation
-- unlike the test-mode guard directly below it, which is documented. Reading it
against the Blizzard source, the overlay bail is scope rather than a constraint:
overlay declares AuraSLOTs, so groupKeys is empty and the GROUP setters genuinely
have nothing to act on, but SetAuraSlotCandidateFilters / SetAuraSlotSortMethod
exist and do the same job per slot.

Overlay now takes a slot branch. The cfByKey derivation is shared unchanged --
build() keys slots and groups identically (rec.key or positional "df<i>") -- as
is the identity-gate re-derivation, which overlay previously never reached. A
slot is one button, so there is no maxFrameCount or layout to push; sort still
matters because it decides which aura wins the slot.

MISSING stays on the Rebuild path, now with a comment saying why: its layout-push
inversion is load-bearing and hard-won (the badge only clears the clip window
because one blank button's layout CELL pushes it out), and a live candidateFilters
swap changes precisely which buttons exist. Enabling that blind is not worth it.

Inert until the Factory's overlay sites move the spell map out of their struct
sigs -- they still Rebuild today, so nothing reaches this path yet. Committed
separately so the engine change is bisectable on its own.
Activates the slot-tuning path added in 10d6048e. The tracked spell-ID map comes
out of the struct sig at all four overlay sites -- health mirror (both the flat
tint and filled mirror branches), background tint, border, and the Text Designer
mirror host -- and rides placedTuningSig instead, so a selection edit stops
teardown-and-recreating a container whose frames can never be reclaimed.

What stays structural is unchanged and deliberate: wholeBar picks a different
config builder, drawAbove changes the create-time level, and the pool filter
string still binds at build.

Two hazards handled rather than assumed away:

  * The filled-mirror branch must NOT clear frame.dfADHealthMirror on a tuning
    pass. The create and rebuild branches clear it because the slot is torn down
    and onBar re-stashes the new StatusBar; a tuning pass keeps the same slot and
    the same bar, so clearing it would strand the reference with nothing to
    re-stash.

  * The mirror-host site has a fourth branch (the TD-teardown host recovery), so
    the tuning check is inserted as another link in that elseif chain rather than
    folding the tail into an else. That preserves its one-action-per-pass shape;
    the sync runs every tick, so a second pending change lands on the next one.

The two MISSING-mode sites keep their maps in the struct sig on purpose --
applyGroupTuning still returns early for that mode (see 10d6048e).
…acks

resolveAppearance is DanderBot#2 in every combat trace (13.9% dungeon, 16.1% boss) and
allocates up to four tables per call: two `or {}` fallbacks, the white colour
fallback, and the returned table.

The class-colour branch in UpdateOne called it a SECOND time for the same elem
and globalDefaults -- a deterministic function with identical inputs -- purely to
read one alpha. applyAppearance had already resolved exactly that and its result
is still live in scope (it is passed to mirrorElement further down). Reuse it and
the whole second resolve disappears for every class-coloured element, every tick.

The two `or {}` fallbacks and the white colour now point at module-level shared
tables. Verified safe: every use in TextDesigner reads these fields and nothing
writes them, and mirrorElement reads app.font/fontSize/outline immediately rather
than retaining the table.

☠ Deliberately NOT sharing the returned appearance table. Before the change above
two results were live simultaneously -- the outer one bound for mirrorElement and
the inner class-colour one -- so a single scratch table would have aliased and
corrupted. That trap is gone now that the inner call is, but one aliasing hazard
in a function is enough reason not to introduce a second for one table per call.
DF:SafeSetFont is one of the hottest functions in the addon and PreloadFont ran
on every single call -- 5.7% of a boss trace's allocation, 15% combined with
SafeSetFont itself, and the largest anonymous-CPU cluster in the login trace.

PreloadFont exists only to force WoW to load a font file. A path needs that once:
the file does not unload and a path IS the file, so the mapping never changes.
Now memoised per path. Keyed on ATTEMPTED rather than succeeded, which preserves
the old behaviour exactly -- the pcall result was discarded, so a failed load was
never retried before either.

Checked that neither fontFamilies wipe (ClearFontCache, RegisterFontAlphabetSupport)
needs to reset it: both exist to rebuild families with updated ALPHABET support,
which says nothing about whether a file is loaded. The memo stays valid across both.

Also dropped the two per-call closures: PreloadFont's pcall takes the
pcall(fn, args...) form, and the SetFontObject pair is hoisted to a file-local so
its pcall can too. That one is two statements -- the GameFontNormal set first is
deliberate -- so it needed a named helper rather than a bare method reference.
BuildSpec calls k(suffix) 37 times and each one concatenated prefix .. suffix
fresh, so every call allocated 37 strings. It shows up in both trace families --
2.7% of a boss run's allocation and 2.6% of a live rebuild's -- because it runs
per border per restyle and again per border per rebuild.

The built keys are now cached per prefix and reused. prefix .. suffix is
deterministic, so a cached key can never go stale.

Checked the memo cannot grow without bound, which is the thing that would turn
this from a fix into a leak: every call site passes a literal from a small fixed
set ("", frame, pet, buff, debuff, defensiveIcon, resourceBar, targetedList,
missingBuffIcon, personalTargetedSpell, personalTargetedSpellImportant), and the
two indirect sites (borderSpec.prefix, a pass-through prefix) resolve to the same
literals. Nothing derives a prefix per frame, per aura or per indicator.

k() stays a closure because it now captures the per-prefix memo, so this trades
37 allocations per call for 1. Left as a closure deliberately rather than
rewriting all 37 call sites to a different form for one more allocation.
TargetedList_LayoutBars called TargetedList_ApplyBarAppearance for every active
bar on every render. That function is pure config -- everything it writes derives
from db alone, never from the spell, the bar's state or the clock -- so nothing it
did could differ between two renders with unchanged settings. It was 86% of all
Border:Apply allocation in a combat trace (BuildSpec + Apply plus three or four
SafeSetFont calls, per bar, per tick).

Gated on targetedListLayoutVersion. That counter already existed at the top of the
file and DF:UpdateTargetedListLayout already incremented it -- but nothing read it,
so the mechanism was there and unused. It is the right gate because that hook is
the single funnel for every settings change: the Options pages call it, preset
application calls it, and a profile switch reaches it through Position.lua. A
bumped version means "config changed", which is exactly when this work is needed.

The hook's own comment already described the intended design -- appearance and
content applied at acquisition, re-applied on settings change -- so the per-render
call was the anomaly, not the mechanism.

☠ TargetedList_ResetBar now clears the stamp on release, immediately after the
_lastTexturePath it already cleared for the same reason. Without that a recycled
bar would skip the whole function and never get its status-bar texture reset,
since forcing that re-set is precisely why _lastTexturePath is nil'd there.

Colour-picker drag is unaffected: LightweightUpdate*Color write the widgets
directly and deliberately bypass this path.
DataSource.Live allocated a fresh table plus a setmetatable on every call, and it
is called once per frame per Text Designer update -- 520 KB in a boss trace, and
100% of that function's allocation.

The wrapper carries nothing but the frame and its unit, and it is now reused per
frame with both fields re-stamped on every call, so a retargeted or recycled frame
still resolves against the correct unit.

Checked the two things that would have made this unsafe:

  * No LiveSource method writes to self -- they are all pure reads -- so a reused
    instance cannot carry stale per-update state.
  * The instance does not escape the update. There is exactly one call site
    (Render.lua) and it passes the source straight into Render:UpdateFrame, which
    only calls methods on it and never stores it.

Falls back to a one-off instance when there is no frame to hang the cache off.
Render:UpdateFrame built a fresh liveIds table on every call to work out which
FontStrings belong to deleted elements. It runs per frame per update and was 475 KB
of a boss trace.

Now a module-level scratch, matching enabledScratch a few lines above which already
does exactly this in the same function. It needs its own table rather than sharing
that one: enabledScratch holds only the ENABLED ids, this holds ALL of them.

Safe against re-entry: the table is wiped immediately before it is filled and
consumed straight after, and nothing between those points can call back into
UpdateFrame -- the fill loop is a plain ipairs and the consume loop only calls
FontString:Hide().
…ilters

With the Blizzard category filters enabled (rather than Show All), the Important
Debuffs highlight silently did nothing for a large share of the auras it should
have covered. Reported as "boss/role/priority seems to be missing one of them" --
some important debuffs highlighted, some did not.

Cause was a precedence inversion. The bossrole and priority records carried
neg(true, true, true), so they EXCLUDED anything dispellable, CC-flagged or
raid-flagged. That made the important categories the lowest precedence of the
five: a priority debuff also carrying the RAID token was pushed out of the styled
priority record and into the unstyled raid one. Most boss and priority debuffs in
group content do carry RAID, hence "some highlight, some don't". Show All mode was
never affected because it builds its exclusivity a completely different way.

Fixed by making category mode match the mode that was already correct. Show All
(the ALL-mode block earlier in this file) declares the important records FIRST
with no negation and subtracts them from the rest via candidateFilter flags
(isBossOrRoleAura = false, isPriorityAura = false). Category mode now does the
same: bossrole and priority claim their auras first, and the cc / raid / dispel
records subtract whichever important records were actually declared.

Verified against the Blizzard source that a false candidate filter is an explicit
negation rather than "absent" -- Blizzard_AuraContainerUtil.lua guards each flag
with `~= nil` and then compares for equality, so false means "must NOT be".

Exclusivity is still complete, so nothing double-renders: every non-important
record subtracts both important flags, and the existing dispel > CC > raid token
ordering between them is untouched. Users with none of boss/role/priority enabled
get an empty subtraction table, which collapses to the same nil candidateFilters
as before -- no change at all for them.

Also dropped neg()'s now-dead excludeRaid parameter. Raid is the last token record
and the important records no longer negate anything, so no call site could ever
pass it true again.

⚠ This moves auras between rows, not just their styling: a raid-flagged priority
debuff now renders in the priority record instead of the raid one, and therefore
also leads the row. That is the documented intent of "important debuffs lead the
row", but it is visible beyond the highlight itself.
Test mode declared one AuraGroup per preview slot, and every AddAuraGroup eagerly
creates FrameCreationBatchSize (10) button frames BEFORE maxFrameCount is applied.
So a 10-icon preview cost 10 groups x 10 frames = 100 button frames per container,
per unit frame. Measured at 40 test frames that was the single largest allocation
anywhere in the addon -- ~530 MB across four toggles, 65% of the test-mode trace --
and the re-measure after the rest of this batch showed it unmoved at -1%, because
nothing so far had touched the group COUNT. Krathe: "it still feels very slow
building out those 40 frames." It was.

Now two groups: the styled slot keeps its own (so declaration order still pins it
to position 1 -- the whole point of the original split, one styled icon leading a
row of plain ones), and every remaining slot shares one group. That is 20 frames
instead of maxCount x 10.

_makeInitializeFrame gains seqStart: with it set, a group numbers its own buttons
sequentially instead of taking a fixed index or the handle-wide creation counter.
The shared counter cannot serve here because Blizzard creates a whole batch per
group, so it runs well past the preview's slot range. Overshoot is harmless --
_paintTestSlot already wraps the index modulo the pool size, and buttons past
maxFrameCount are never displayed.

⚠ TRADE-OFF, stated plainly: inside the shared group the flow assigns auras to
buttons in the CONTAINER's order, not creation order, so the plain entries may
appear in a different order than the curated pool lists them. It is deterministic
(test mode declares no sort, so the same samples land the same way every build) and
every entry is still shown. The "Lightning Shield mid-row" failure the per-slot
shape was built to prevent was about the STYLED entry drifting, which declaration
order still guarantees, and the mismatched-tooltip half is moot since tooltips are
now forced off in test mode.

Checked nothing else constructs or matches the old dfTest<N> keys. Updated two
comments that described the retired per-slot design.
Scale, size and position of the settings window were stored in db.party, so
they travelled with the profile. A new profile is born from PartyDefaults,
which meant creating one silently reset guiScale to 1 -- but only for the
readers that re-read the db. The Test Mode and Unlock windows do that on
every OnShow, so they snapped to 100% while the already-open settings window
kept the old scale, and only moving the UI Scale slider (the single writer)
put them back in step.

ExportCategories has always declared these keys local-only 'machine/window
state, not profile content'. Storing them account-wide on
DandersFramesDB_v2.windowState is what enforces that: a profile switch or a
fresh profile can no longer move, resize or rescale the window.

Seeded once at login from the active profile, then stripped from every
profile (both modes -- RaidDefaults is copied from PartyDefaults, so each
profile carried a dead db.raid set too). Same shape as the languageOverride
per-profile -> per-character move already in this block.
The dispatcher's unknown-word fallback loads the companion and retries, so
tools that register their slashes only on load (icons, colorhook, atlas,
auraexp, memtest) still answer before /df has been opened. But 'recognised'
was read from DebugSlashBySub, which only RegisterDebugSlash fills. The 38
commands registered through RegisterDebugSub -- every hand-written branch in
this addon, zorder and auradata and ppdump among them -- were invisible to
that lookup, read as unknown, and pulled in ~3 MB of settings UI before
running code that never needed it. One diagnostic on a fresh login gave back
the whole saving of splitting the addon in two.

Worst for the probes that exist to measure memory: loading the companion
perturbs exactly what they report.

RegisterDebugSub already sees every name and already builds a set one line
below for the dev flag; record the names the same way and test that set
before the load. Registering IS the gate, so a branch added later is covered
the day it is written.

Verified no sub() name collides with a companion-registered slash, so the set
cannot swallow a word that genuinely needs the load. The sub() branches that
do need the companion (profiler and three others) call EnsureOptionsLoaded
themselves, at the point of need.
…gns none

UnitGroupRolesAssigned answers 'what role did the GROUP assign', not 'what
does this player do'. It returns NONE solo, in the open world, and inside a
delve until something forces an assignment. ShouldShowResourceBar mapped that
NONE straight onto DAMAGER, so a Holy Paladin standing in Silvermoon was
gated by the DPS checkbox: ticking Healers did nothing, and ticking DPS
showed the bar for every spec regardless of Show in Solo Mode. In a delve the
role only resolved after a spec change.

Two copies of the correct fallback already existed (frame border colour, role
border colour) with near-identical comments. Promoted to DF:GetUnitRole and
routed all three call sites through it. Resolution only, no policy: it can
still return NONE, and callers keep deciding what that means -- other units
expose no public spec API, so the NONE arm stays for them.

Border.lua / Core.lua are behaviour-preserving: GetUnitRole returns nil for a
nil or non-existent unit, exactly what their old guard did.
In a delve Brann makes IsInGroup() true but no role is ever assigned, so
UnitGroupRolesAssigned stays NONE and UpdateRoleIcon's NONE arm hid the icon
for the whole run -- until a spec change made the game assign one, which is
exactly what the reporter saw.

Resolved through DF:GetUnitRole, but gated on being in a group. Solo the
question is not being asked: resolving there would put a permanent role icon
on your own frame in the open world, where there has never been one. So this
is deliberately NOT the resource bar's policy, which wants the spec role
everywhere. The split lives in one place, DF:GetRoleIconRole.

☠ The dirty check in ProcessRoleUpdate had to move with it. That cache decides
whether UpdateRoleIcon runs at all; keyed on the raw assignment while the icon
displays a resolved role, the resolution is unreachable -- across a delve the
raw value never leaves NONE, the cache sees no change, and the icon is never
asked to redraw. Both sides now call GetRoleIconRole, so the cache key and the
drawn value cannot drift.

Left alone: Headers.lua's role SORT still reads the raw assignment. Sorting by
the assigned role is its own question and changing it is a separate decision.
The three highlight frames were pinned at owner level +9 / +10 / +11 -- offsets
written when a unit frame's children topped out around +4 and anything above
the health bar counted as on top. The frame-level rework made every DF element
an ABSOLUTE offset from the unit frame, and the stack now runs resource bar 20,
contentOverlay 25, status icons 30, missing buff 35, aura rows 40, defensive 65
(verified against Config, not the layer-map doc). At +10 the hover highlight was
under all of it. 'The resource bar draws over the hover highlight' is the
shallowest instance of that, not the whole of it.

Safe to raise because every mode draws at the frame PERIMETER -- SOLID, CORNERS
and DASHED are edge lines, GLOW is an edge glow -- so none of this can obscure
the name or health text. Relative order is preserved: aggro < hover < selection.

82/84 rather than 78/80: 65 is the highest default and a row's art sits above
its baseline by +12 or +14 depending on the element, so the real ceiling is 79.
The layer-map doc disagrees with itself on that figure, so these clear it rather
than landing on the boundary, where one off-by-one would silently re-bury the
highlight. Still far under the movers' absolute 100.

Also re-applies strata and level on the REUSE path. The level is absolute and
derived from the owner's at the moment it is set, so a highlight created before
the owner's level changed kept a stale one for the rest of the session.

⚠ NOT yet verified in game -- needs /df debug zorder against a frame with
defensive icons and auras up.
Drag-to-reorder only dropped where you aimed at 100% UI Scale. Reported for
Role Priority and Group Display Order; it is the same defect in six widgets,
and it is on Live too.

GetCursorPosition returns a value you divide by a frame's EFFECTIVE scale to
get that frame's coordinates. These divided by UIParent's -- but every one of
them then compares against frame:GetTop() / GetLeft(), which is already in the
frame's own space, and the settings window carries the user's UI Scale on top
of UIParent's. So the two operands were in different coordinate systems,
off by exactly that factor: at 140% the cursor reads 40% further from the
screen edge than it is, and the drop lands somewhere else. Above and below
100% both break, in opposite directions.

Not a new idiom -- the colour picker and the Click Casting keybind popup
already divide by their own frame's scale. These sites just did not.

DF.GUI:CursorPos(frame) now holds it in one place. It takes the frame
deliberately rather than assuming, because the exception is real: the Aura
Designer's drag ghost is PARENTED AND ANCHORED to UIParent, so UIParent's
scale is the correct divisor there and that site is left alone.

17 sites: the four reorder widgets in Controls.lua (role, class, group,
highlight roster), the Nicknames row reorder, and CreateRangeSlider -- which
had it on the X axis, so grabbing a handle picked the wrong one and clicking
the track jumped to the wrong value.
resolveAppearance is DanderBot#2 in every combat trace and ran once per element per
frame per tick. ac55fb8 shared its three fallback tables, leaving exactly one
allocation per call -- and that one was 55.7% of all remaining steady-state
allocation, because of how often it runs.

ac55fb8 declined to share the return table, correctly at the time: the
class-colour branch re-resolved the same element just to read one alpha while
the outer result was still bound for mirrorElement, so two results were live at
once and a shared table would have aliased them silently. That commit removed
the second call. The hazard went with it.

Verified before sharing rather than trusting that: applyAppearance is the only
caller of resolveAppearance and updateOne the only caller of applyAppearance;
resolveAppearance is a file-local so nothing outside Render.lua can reach it;
mirrorElement reads app.font/fontSize/outline at the point of use and retains
nothing; and all five fields are reassigned every call so nothing stale carries
over. The invariant and what would break it are written at the declaration.
Reported as an imported profile arriving with raid frames in columns instead
of rows, reproducible on every import. The import was never at fault: the
value transferred correctly and the post-import refresh re-synced over it.

3b912fb0 added growDirection and growthAnchor to general_frame's Copy/Sync/
Reset prefix list, as part of an audit for keys no section owned. That commit
already carried the argument against it, for the 14 raid* layout keys it
deliberately left out: per-mode keys that BOTH pages edit must not be owned
by one list, because the same list drives Copy, Sync and Reset, so owning
them lets party's value overwrite raid's. growDirection is exactly that case
and escaped the exclusion only because it is not spelled "raid...".

Consequence went well beyond import: with Sync on, raid could never hold a
different growth direction from party at all -- any change was overwritten on
the next refresh.

☠ Nearly invisible by construction. The sync copies every key under these
prefixes, but the two modes agree on almost all of them, so the only evidence
is the one key where a user's party and raid genuinely differ. Diffing an
imported profile shows 34 of 35 layout keys matching and one wrong, which
reads like a single dropped key rather than a rule firing on everything.

The raid dropdown inverts the labels for this key (HORIZONTAL reads
"Columns" there, "Rows" on party and raid-flat) -- these were never one
setting.

Audited the rest of that commit and its sibling de06a13: the other seven
additions (permanentMover, combatIcon, directBuff, directDebuff, defensiveBar,
useFrameSort, and the Fading renames) are all keys only ONE page edits, so
they carry no cross-mode hazard and stay.
DF:SafeSetFont was 40.9% of all boss-fight allocation and 12% of trash, on two
allocations per call -- and it runs once per FontString per frame, twice over
for any element carrying an AD mirror.

1. outline:match("^SHADOW;(.*)$") -- string.match WITH A CAPTURE allocates the
   captured substring every call. The parse is a pure function of the raw
   outline string and those strings come from a handful of dropdown options, so
   it memoises once and never goes stale. The SLUG concat caches with it, since
   that variant derives from the parsed flags alone; toggling fontSlug just
   selects between two precomputed strings.

2. FontFamilyKey built a four-part concat to PROBE the family cache, so it ran
   on every hit, not just on a miss. Memoised through a nested table: three
   small tables per new (font, outline, size, shadow) combination and nothing
   after. The key stays a string, so fontFamilies is still string-keyed and the
   broken-family eviction in SafeSetFont still finds its entry.
Four steady-state allocators, 16.9% of trash-fight allocation between them.

GetDurationColorBreakpoints (6.6%) rebuilt the whole ladder per aura per tick --
a list table, a table per breakpoint, and a colorToHex string per breakpoint --
from account-wide config that only changes on a Colours-page edit. Memoised per
scale. Verified all five callers only read it before sharing the list. Keyed on
the resolved def table so an unknown scale shares TEXT_SECONDS' entry rather than
duplicating it. Invalidation rides DF:InvalidateDurationFormatters, which every
breakpoint edit already fires and whose comment already made it mandatory -- and
PERCENT now caches too, where the signature memo only ever covered SECONDS.

subSig (2.1%) allocated a keys table and a parts table per call, inside
syncPlacedPool -- a walk whose whole purpose is to be allocation-free. ☠ It
RECURSES, so the scratch is PER DEPTH: a single shared pair would let a nested
call wipe its caller's half-built list. Depth is hard-bounded at 4 by the
existing guard, so one pair per level is sufficient.

placedBorderRawSig (4.8%) got a single shared scratch -- it cannot recurse, only
reaching colSig and ppSigToken, neither of which calls back into it.

colSig (3.5%) built a four-element array just to tconcat it. A chained concat
compiles to one concat over a register range, so the result string is now the
only allocation.
Krathe82 added 10 commits August 2, 2026 21:53
…nged

22.4% of trash-fight allocation and 9.7% of boss. It is target-switch driven,
so it re-ran the full hide-everything-then-re-apply pass on every target change
even when the resulting art was identical.

Deferred once already because an early-out here is not the two-line change it
looks like. Four hazards, all handled:

1. ☠ THREE IMPLICIT INPUTS beyond the eight parameters. ch:GetEffectiveScale()
   (thickness and inset are pixel-snapped against it), ch:GetWidth()/GetHeight()
   (CORNERS derives cornerLen from them), and db.pixelPerfect. Miss one and the
   art goes stale with nothing on screen to explain it. The scale read for the
   signature is the SAME local the snapping uses, so what is cached and what is
   applied cannot disagree.

2. ☠ AN EXTERNAL WRITER. DF:LightweightUpdateHighlight writes size, points and
   colour straight onto the four line textures during a slider drag, bypassing
   this function entirely. It now invalidates first, or the next full update
   would see a matching signature and leave the drag values in place forever.

3. ☠ THE ANIMATOR. The three not-wanted branches call SelectionAnimator_Remove.
   Turning an ANIMATED highlight off and back on with identical settings would
   have early-outed and never re-added it -- an animation that silently stops.
   All three invalidate.

4. Fields are compared individually rather than through a string key: building
   a key would allocate exactly what the early-out exists to avoid.

Swept for other writers of topLine/bottomLine/leftLine/rightLine outside
Highlights.lua -- LightweightUpdateHighlight is the only one.
395af09f memoised the 37 db-key strings but left one allocation: k captured
memo and prefix, so a closure was constructed on every call. BuildSpec runs per
bordered element per tick -- 4.6% of trash allocation and 4.3% of boss.

☠ The upvalues are shared, safe only because BuildSpec cannot re-enter. Checked
its body rather than assuming: it reaches DF:GetClassColor, DF:GetTestUnitData,
DF:GetUnitRole and the Border:Resolve* helpers, none of which call BuildSpec.
Recorded at the declaration, because if a resolver ever does build a spec this
starts reading another prefix's keys and produces a wrong border with nothing at
the call site to explain it.

Also noted while measuring, not a change: DF:GetDB shows at 3.5% of trash CPU
but is two branches and a table index -- that is Perfy's ~1us per-call
instrumentation, not real work. Same caution applies to any tiny function high
in the CPU list; optimising those would be optimising the profiler.
Two per-call allocators on the proc/animation paths -- 3.0% of trash allocation
and 5.6% of boss.

C_Texture.GetAtlasInfo RETURNS A FRESH TABLE every call, and setupProcGlow asked
for two of them per call. Both atlases are compile-time constants, so the info is
fetched once and shared. Resolved lazily, not at file scope, because C_Texture
may not be up when this file loads -- and the latch only sets once the API
actually answers, or a nil before the texture system is ready would cache "no
atlas" for the session and the proc glow would never draw again.
Safe to share: swept _procAtlas / _procStartAtlas and every consumer only reads
(.file, and stepProcFlipbook's frame maths).

Seven sites wrote readColor(anim.color or { ... }) with the table inline, so any
animation with no colour set built a throwaway on every driver tick. Hoisted to
two shared constants. readColor only reads its argument and nothing retains it.
A raid header keeps its whole child set alive and hands units out as the
roster fills, so outside a full group most children carry no unit at all.
UpdateAllDispelOverlays walks IterateAllFrames unfiltered, so every one of
those empty slots got a full six-slot CustomAuraContainer stood up for it --
hidden, unable to ever match a dispellable aura, and (because Create defaults
a nil unit to "player") pointed at the wrong unit.

Measured at login while solo: 41 containers built, 40 of them empty raid
slots. That is 10.8 MB of the 14.1 MB the addon spent standing containers
up, plus 3.3 MB of BindDispelCarriers closures -- ~76% of container
construction for frames nobody can see.

The aura rows never paid this because they come through FullFrameRefresh,
which already returns on a unitless frame; every other IterateAllFrames
consumer (StatusIcons x3, Headers, Core, FindFrameByUnit) gates the same
way. This was the only unguarded one.

Existing handles are deliberately left alone: the roster churns, and SetUnit
re-targets a live one for free when a unit lands in the slot.
Two independent construction costs.

bindNative ran twice per BUTTON: a pcall wrapper closure, and a fresh
textColor table whenever a colour curve is configured. It was the single
largest profile-switch item at 24.9 MB. The colour pair now caches on the
config by spec identity -- the same pattern _dfDurBind two lines above
already used, invalidated for free by the fresh style table a structural
Rebuild hands over -- and the pcall passes its args instead of capturing
them, matching the AddAuraGroup call in build().

SetTestMode's rebuildAll walked every handle. A container genuinely has to
be rebuilt across a test transition (the two shapes declare different groups
and groups can never be removed), but that only ever applied to the handles
that RENDER the preview. Live frames are hidden for the whole session by
SetTestModeStateDrivers and their containers are built deaf, so they ignore
the provider bounce and sit on real data untouched from entry to exit --
rebuilding them into test shape and back bought nothing.

Provenance is resolved once at Create by a bounded walk up the anchor's
parent chain for dfIsTestFrame, which TestFramePool and CreatePlayerTestFrame
both stamp at frame creation, long before any row is driven onto them.

Note the entry pass is NOT redundant, despite reading that way: the test
frame pool is created once behind testFramePoolInitialized and its frames
are only hidden on exit, so from the second entry onward the test handles
are present and do need the rebuild.
AddAuraGroup hardcodes a frame batch and creates it BEFORE maxFrameCount is
applied, so a group that can only ever show one icon still paid for a whole
batch. AddAuraSlot creates exactly one frame and builds the same comparator from
sortMethod/sortDirection, so a max=1 group and a slot pick the same winner.

Seven Aura Designer configurations showed a single icon and created a batch
each. The engine grows a singleSlot row mode -- one AddAuraSlot pinned to the
container's flow anchor, carrying the row's padding so the duration strip keeps
its reservation -- and those seven opt into it. Falls back to the group path if
a config claims singleSlot with anything other than exactly one filter.
Reviewing the pass end to end turned up four things it had broken or left
half-done:

- Highlights: ch:Show() sat BELOW the _hlSig early-out, so a container whose
  signature was unchanged never got shown again -- the selection border vanished
  after leaving Test Mode.
- Single-slot rows dropped the duration-strip reservation, so a row with
  duration text lost its bottom padding.
- Border latched the proc atlas on the first of two atlas lookups, so a missing
  second atlas left the latch claiming resolved.
- The test-mode bounce re-enabled every handle it had disabled, including live
  ones, undoing the rebuild scoping on the way back out.
Every widget hung off a dispel slot button -- the overlay frame, its border
StatusBars, the bound carriers -- is a descendant of the secret aura button, and
the client can turn that subtree forbidden when it reclaims or re-initialises the
slot. btn.dfDispelWidget was never cleared on any path, so the stash outlived the
art and ApplyOverlayLayout's first touch (borderLeft:ClearAllPoints) threw on
every state-changing refresh. The throw aborted StyleGameMainSlot, so the whole
overlay died on that frame, not just the border geometry.

Reported on alpha 14 at 32x after a dungeon fight and 98x on a single Show
Overlay For dropdown -- same frames, once per refresh, so it persists rather than
clearing itself. Same defect class as bug #1004 (the health mirror, 999x in a
follower dungeon); Frames/Core.lua already carries this guard for that consumer.

StyleDispelSlots now probes the stashed art before anything paints it and, on
failure, drops every stash on the button and re-runs DispelSlotSecureInit to
rebuild and re-bind. One retry per button, latched, so art that comes back
untouchable degrades to a skip with a single debug line instead of rebuilding
every pass; the latch clears as soon as a probe passes, so a later reclaim is
still recoverable.

The probe is read-only and touches borderLeft -- the object that actually throws
-- rather than IsForbidden(), which inside the secret container can hand back a
secret on healthy art and would read as "skip" for everyone.

NOT verified in game: needs the #1004 repro (follower dungeon, roster churn) to
confirm the rebuilt art is touchable and the overlay comes back.
The v5 lane rebuilds the aura row anyway, so the "don't change a look every
user already has" reason for shipping it off no longer applies. Party and raid
both pick it up -- RaidDefaults is derived from PartyDefaults and the key is not
in RAID_DEFAULT_OVERRIDES.

Only the shipped default moves. Profiles are a deep copy of the defaults taken
at creation (Core/Profile.lua:59), so existing profiles keep their stored false;
this changes new profiles only.
The duplicate-icon known issue ('a debuff that is both Priority and Boss/Role
can show one icon per matching filter') no longer describes the code. The
priority record subtracts boss/role via its own candidateFilters flag, and the
crowd-control, raid and dispel records subtract both -- every record is mutually
exclusive by construction, in category mode and in Show All alike.
@Krathe82
Krathe82 changed the base branch from main to feature/aura-factory-12.1 August 3, 2026 12:07
@DanderBot
DanderBot merged commit 55ddb7b into DanderBot:feature/aura-factory-12.1 Aug 3, 2026
Krathe82 added a commit to Krathe82/DandersFrames that referenced this pull request Aug 3, 2026
The hoisted k() reads two file-local upvalues that BuildSpec set on entry. That
was safe only because BuildSpec cannot re-enter -- an invariant held by a comment
and nothing else. A resolver that ever built a spec would have started reading
another prefix's memo: a wrong border, silently, with nothing at the call site to
explain it.

BuildSpec now saves and restores the pair around its body, so reentrancy is
correct rather than forbidden. Two locals, no allocation -- cheaper than the
debug assert that would only have caught it on a dev build.

Raised by Danders reviewing PR DanderBot#224.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants